Linux Readahead 预读机制分析(Linux 4.14)
The following article is from 酷派技术团队 Author Vier
一、基本概念
设计背景
文件访问类型一般是顺序的,访问[A, B]范围的数据后,接下来很可能访问[B+1, B+N]数据。由于访问磁盘、flash等存储器件比较耗时,在访问 [A, B]的时候,如果提前把[B+1, B+N]数据从存储器件读取到ram中,那么后继需要用[B+1, B+N]数据时,就不需要通过耗时的disk io从存储器件读取数据了,从而提高性能。
预读带来的好处
对于顺序读,适量的预读可以提升性能。原因如下:
1)提前读取数据,避免耗时的disk io读取数据
预读数据缓存在page cache中(struct file->f_mapping->page_tree),用的时候,直接从缓存读取,不需要从存储器件读取数据(这个操作耗时)。
2)提高存储栈、器件处理效率
预读本质是提前读取即将访问的数据,以备将来使用,这意味着“当前数据”和“预读数据”的逻辑地址是连续的,这两个io请求在内核存储栈中(filesytem-->block layer-->device)就可以进行合并(merge)处理。处理一个io请求,即可满足原先的两个io请求需求。
3)降低硬中断、软中断带来的负载
器件处理io请求完成时,内核通过硬中断、软中断处理io完成时的工作,合并io请求,可降低中断数量,减少中断带来的额外负载。
4)避免存储栈拥塞导致read响应不及时
器件的io数量是有上限的(默认值/sys/block/sdc/queue/nr_requests=128),当器件待处理的io数超过7/8 * nr_requests时,存储栈进入拥塞状态并限制生成io请求的速度;当待处理的io请求数达到最大值nr_requests时,read因申请不到struct request而阻塞等待,直至一些io请求处理完后被存储栈回收,read、write才能转换成io请求,然后提交io请求给器件处理。合并io请求,可减少io数量,降低存储栈拥塞的概率。
5)避免磁盘磁头来回移动
这一点是针对机械硬盘的。文件数据[A,B]和[B+1, B+N]在逻辑地址上是连续的,磁头只需往一个方向移动。作为对比,访问[A,B],[A-N, A-1],[B+1, B+N],就会出现磁头来回移动的情况,磁头的机械运动是很耗时的。
预读带来的坏处
1)对于随机读,预读的数据不会被用到,白白占用了存储器件带宽及系统内存。
2)大量预读可能导致io负载增大。
3)大量预读可能导致内存压力增大。
同步预读 & 异步预读
源码中有两个函数page_cache_sync_readahead和page_cache_async_readahead,分别称作同步预读、异步预读。这个地方容易让人迷糊,需解释一下二者的区别。
同步预读:从存储器件读取多个page数据,一些page给read使用,一些page留给将来使用。在下图,read文件数据时,根据page->index在page cache中(struct file->f_mapping->page_tree)找需要的页面(蓝色页面),如果找不到就执行同步预读page_cache_sync_readahead。同步预读根据待读数据的逻辑地址、待读page数量及地址信息,封装成bio,然后调用submit_bio提交给器件处理。不过要注意预读提交bio后就返回了,并不会等待page中的数据变成PageUptodate。
异步预读:本次读的page纯粹是为将来准备的,触发异步预读的read目前不需要这些数据。这些page提前从存储器件读取并将page加入到page cache中。
二、关键数据结构及原理
数据结构
预读的核心数据结构是struct file_ra_state,定义在linux/fs.h中:
/*
* Track a single file's readahead state
*/
/* 这个结构体是struct file相关的,描述了file的预读状态。
预读按预读窗口推进,预读窗口中含有file_ra_state->size个page。
read访问预读窗口中的page,访问到第size-async_size个page,也
即预读窗口中还剩async_size个page没有访问时,启动异步预读读入一
批新的page作为预读窗口 */
struct file_ra_state {
//从start这个page开始读
pgoff_t start; /* where readahead started */
//读取size个page放入预读窗口。
如内存不足无法申请page,则预读小于size个page
unsigned int size; /* # of readahead pages */
//预读窗口中还剩async_size个page时,启动异步预读
unsigned int async_size; /* do asynchronous readahead when
there are only # of pages ahead */
//预读窗口上限值(单位: page).
默认等于struct backing_dev_info->ra_pages, 可通过fadvise调整。
如果read需要读的page数量小于ra_pages,最多读取ra_pages个页面.
如果read需要读的page数量大于ra_pages,最多读取 min { read的page数量,存储器件单次io最大page数量 }个页面.
预读窗口中当前有多少个页面由size成员变量表示.
unsigned int ra_pages; /* Maximum readahead window */
unsigned int mmap_miss; /* Cache miss stat for mmap accesses */
//最后一次的读位置(单位:字节)
loff_t prev_pos; /* Cache last read() position */
};
关键字段意义如下图:
原理
read请求N 个page数据,通过预读从存储器件读取M个page数据(M > N),并对其中的第a个page设置PageReadahead标记(0 ≤ a < N)。PageReadahead标记起到一个标识作用,表示预读窗口中剩余的page不多了,需要启动异步预读再读入一批page存放page cache。
如果是顺序读,肯定会读到PageReadahead标记的页(单线程读的场景),或者读到预读窗口的最后一个页(多线程穿插读的场景),所以代码作者认为,如果访问到这两种页就是顺序读(注意:这样的判断不是很准,但代码写起来简单高效),否则认为是随机访问。
如果是顺序访问,预读窗口在之前的基础上扩大2倍或者4倍(上限值不超过struct file_ra_state->ra_pages),然后从存储器件读入数据填充这些page,并缓存在page cache中,最后,在新读入的这批page中选定一个page设置PageReadahead标记(一般是新读入的这批page中的第一个page)。
如果是随机访问,预读机制仅从存储器件中读取read函数需要的数据,read请求几个page,就读几个page,并缓存在page cache中。可以看出随机读不会预读多余的page,另外注意随机读不更改预读窗口。
三、预读示例
按 4k 顺序访问文件的[0, 8*4K]的数据(顺序访问),然后lseek到108*4k处访问文件(随机访问)。过程如下:
1)访问第0个page数据
page->index=0的页面在page cache中找不到,触发同步预读page_cache_sync_readahead,一次性读了4个page(read需要1个page,预读3个page。预读page数量与实际请求的page数量、file_ra_state->ra_pages有关,通过get_init_ra_size计算)。本次预读建立的预读窗口如下:
注意,预读窗口中第ra->size - ra->async_size = 1个page,即page->index=1设置了PageReadahead标记。
2)访问第1个page数据
page->index=1的页面在page cache中能找到(预读命中),不需要从存储器件中读取数据。又因该page有PageReadahead标记,触发异步预读page_cache_async_readahead,预读页面数量由get_next_ra_size计算得到,因为本次请求的数据起始位置与上一次读结束位置相同,属于顺序读,get_next_ra_size加大预读量,预读量从之前的4 page增大到8 page。本次预读建立的预读窗口如下:
注意,预读窗口中第ra->size - ra->async_size = 0个page,即page->index=4设置了PageReadahead标记。
3)访问第2、3个page数据
这两个page在page cache中可以找到(预读命中),直接从page cache中读取数据。
4)访问第4个page数据
page->index=4的页面在page cache中可以找到(预读命中),直接从page cache中读取数据。不过这个page设置了PageReadahead标记,触发异步预读page_cache_async_readahead,由于是顺序读,get_next_ra_size将预读量从8 page增大到16 page,本次预读建立的预读窗口如下:
后继的顺序访问流程重复上面过程,遇到page被标记成PageReadahead,增大预读量(最大不超过struct file_ra_state->ra_pages)后启动异步预读。
5)lseek跳到108*4k处访问第108个page数据
访问page->index=108的page,不符合顺序读的条件,所以代码判断成随机读。如果是随机读,则不做预读,read请求几个页的数据,就从存储器件中读几个页数据(ondemand_readahead --> __do_page_cache_readahead)。
注意,这次随机读,不会更改预读窗口状态。
四、关键代码分析
同步预读generic_file_buffered_read --> page_cache_sync_readahead 与 异步预读generic_file_buffered_read --> page_cache_async_readahead都会调用ondemand_readahead,在这个函数中计算预读量,然后ra_submit提交io请求。
/* @offset: 从哪个page开始读
@req_size: 需要读多少个page,这是read需要的数量
如果是顺序读,ondemand_readahead读取至少req_size个page。
如果是随机读,ondemand_readahead仅读取req_szie个page。
*/
ondemand_readahead(struct address_space *mapping,
struct file_ra_state *ra, struct file *filp,
bool hit_readahead_marker, pgoff_t offset,
unsigned long req_size)
{
struct backing_dev_info *bdi = inode_to_bdi(mapping->host);
unsigned long max_pages = ra->ra_pages;
unsigned long add_pages;
pgoff_t prev_offset;
/*
* If the request exceeds the readahead window, allow the read to
* be up to the optimal hardware IO size
*/
/* read请求的page大于预读窗口大小,预读的page数量上调至存储器件单次io最大的page数量 */
if (req_size > max_pages && bdi->io_pages > max_pages)
max_pages = min(req_size, bdi->io_pages);
/*
* start of file
*/
if (!offset)
goto initial_readahead;
/*
* It's the expected callback offset, assume sequential access.
* Ramp up sizes, and push forward the readahead window.
*/
/* 条件1:offset == ra->start + ra->size - ra->async_size
/ 同步建立的预读窗口 \
/ \
|----------------------------------------|
| | | | ^ | | | | | | |
|--------------|-------------------------|
|
ra->start + ra->size - ra->async_size
该page设置了PageReadahead
上图是同步预读建立的预读窗口,从第0个page到第ra->size - ra->async_size
个page,是read需要用的,后面的page是预留给下次read用的。
读到第ra->size - ra->async_size个pag,表明预读窗口已经开始使用,
需要启动异步预读,读入一批page建立一个新的预读窗口,为下下次read做准备。
条件2:offset == ra->start + ra->size
前一次 / 异步建立的预读窗口 \
预读窗口 / \
-----------|-----------------------------------|
| | | ^ | | | | | | | | | | | |
------------|-----------------------------------|
|
ra->start + ra->size(前一次预读用完了,执行下一个预读窗口的第一个page)
该page设置了PageReadahead
异步预读建立的预读窗口中,第一个page标记为PageReadahead,访问到这个标记的
页面,表明新预读窗口已经开始使用,需要启动异步预读,读入一批page建立一个新的
预读窗口,为下下次read做准备。
满足这两个条件,可认为本次read预上一次read是顺序读类型 (见原理部分描述)。
这个条件是不准确的,但是代码实现简单高效。*/
if ((offset == (ra->start + ra->size - ra->async_size) ||
offset == (ra->start + ra->size))) {
ra->start += ra->size;
/*既然是顺序读,就扩大预读窗口:
如果当前预读窗口大小ra->size < 1/16 * 允许读的最多page数
1)则预读窗口在原基础上扩大4倍。否则扩大2倍。
2)扩大后的窗口大小,不能超过“允许读的最多page数量”
*/
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
}
/*
* Hit a marked page without valid readahead state.
* E.g. interleaved reads.
* Query the pagecache for async_size, which normally equals to
* readahead size. Ramp it up and use it as the new readahead size.
*/
/* 多线程顺序读一个文件(interleaved read),破坏了file->ra_state,所以没法根据前面的条件
判断出是顺序读类型。如果是顺序读,最终一定会访问到预读窗口中的PageReadahead标记的page。
所以,反过来推,访问到PageReadahead标记的page,就认为是顺序读。当然这样的判断是不准确的,
但准确率高,代码简单。*/
if (hit_readahead_marker) {
pgoff_t start;
/* offset + 1开始,找到第一个不在page cache中的文件数据页,作为预读的开始 */
rcu_read_lock();
start = page_cache_next_hole(mapping, offset + 1, max_pages);
rcu_read_unlock();
if (!start || start - offset > max_pages)
return 0;
ra->start = start;
ra->size = start - offset; /* old async_size */
ra->size += req_size;
ra->size = get_next_ra_size(ra, max_pages);
ra->async_size = ra->size;
goto readit;
}
/*
* oversize read
*/
if (req_size > max_pages)
goto initial_readahead;
/*
* sequential cache miss
* trivial case: (offset - prev_offset) == 1
* unaligned reads: (offset - prev_offset) == 0
*/
/* 上面的场景根据下面3个条件判断是否是顺序读:
offset == ra->start + ra->size - ra->async_size
offset == ra->start + ra->size
hit_readahead_marker
代码执行到这里,属于随机读场景。
*/
prev_offset = (unsigned long long)ra->prev_pos >> PAGE_SHIFT;
if (offset - prev_offset <= 1UL)
goto initial_readahead;
/*
* Query the page cache and look for the traces(cached history pages)
* that a sequential stream would leave behind.
*/
if (try_context_readahead(mapping, ra, offset, req_size, max_pages))
goto readit;
/*
* standalone, small random read
* Read as is, and do not pollute the readahead state.
*/
/* read请求req_size,__do_page_cache_readahead只读req_size个page。注意2点:
1)__do_page_cache_readahead-->read_pages条件io就返回了,不会等待page变成PageUptodate。
2)__do_page_cache_readahead不会更改 struct file_ra_state。
*/
return __do_page_cache_readahead(mapping, filp, offset, req_size, 0);
initial_readahead:
/* 第一次读文件,初始化预读窗口 */
ra->start = offset;
ra->size = get_init_ra_size(req_size, max_pages);
ra->async_size = ra->size > req_size ? ra->size - req_size : ra->size;
readit:
/*
* Will this read hit the readahead marker made by itself?
* If so, trigger the readahead marker hit now, and merge
* the resulted next readahead window into the current one.
* Take care of maximum IO pages as above.
*/
/* hit_readahead_marker预期建立了一个预读窗口A,待访问offset刚好是这个预读窗口的第一个page,
这说明访问A窗口,后面没有预留的预读窗口了,需建立下一个预读窗口B备用,既然A、B都还没预读,
就合并后一起预读。*/
if (offset == ra->start && ra->size == ra->async_size) {
add_pages = get_next_ra_size(ra, max_pages);
if (ra->size + add_pages <= max_pages) {
ra->async_size = add_pages;
ra->size += add_pages;
} else {
ra->size = max_pages;
ra->async_size = max_pages >> 1;
}
}
/* ra_submit --> __do_page_cache_readahead尽量分配ra->size个page,
分配失败就停止分配,这些page加入page_pool链表。
read-->page_cache_sync_readahead,假设read需要读取nr_to_read个page,
预读M个page,则第nr_to_read个page(从0开始计数)设置PageReadahead标记。
read-->page_cache_async_readahead,则预读的第0个page(从0开始计数)设置
PageReadahead标记。
ra_submit -->__do_page_cache_readahead --> read_pages调用
mapping->a_ops->readpages或者mapping->a_ops->readpage依次读入page_pool
链表中的page。
ra_submit提交io后就返回,不会等待page变成PageUptodate。
*/
return ra_submit(ra, mapping, filp);
}
五、代码待优化的地方
场景
generic_file_buffered_read -->page_cache_sync_readahead或page_cache_async_readahead --> ondemand_readahead --> ra_submit --> __do_page_cache_readahead 会把刚刚申请的一批page(用于预读)加入到page cache中,如果读失败了,这些page并不会从page cache中清除,只是标记成PageError。
问题
generic_file_buffered_read -->find_get_page到page,但不是PageUptodate状态,则认为io错误导致的问题,就通过mapping->a_ops->readpage重新读取单个page数据。所以预读发生io err后,后继read不会批量读入这些page了,只会单个page读,性能较差。
解决
对于这个问题,5.18内核版本已经修复https://lkml.org/lkml/2022/2/10/14。